1. 싱글톤이란
싱글톤에 대한 설명
싱글톤(Singleton)은 디자인 패턴 중 하나로, 어떤 클래스가 최초 한 번만 메모리를 할당하고(static) 그 메모리에 인스턴스를 만들어 사용하는 디자인 패턴이다. 이렇게 하면, 메모리 낭비를 방지할 수 있다.
발전 과정
- 스프링은 기업용 온라인 서비스 기술을 지원하기 위해 탄생하였다.
- 스프링은 웹 어플리케이션이다.
- 웹 어플리케이션은 동시성 문제가 있다. ( 동시에 여러 고객이 요청함)
DI 컨테이너 동작 과정
- 고객이 요청할 때마다 객체를 새로 생성한다.
- 웹 어플리케이션은 고객이 계속 요청을 시도한다.
- 그렇다면 계속해서 고객이 요청한다면?
public class SingletonTest { @Test @DisplayName("스프링이 없는 DI 컨테이너") void pureContainer(){ AppConfig appConfig = new AppConfig(); //case1 호출할 때마다 객체를 생성 MemberService memberService1 = appConfig.memberService(); //case2 호출할 때마다 객체를 생성 2 MemberService memberService2 = appConfig.memberService(); //memberService1 != memberService2 Assertions.assertThat(memberService1).isNotEqualTo(memberService2); } }
- 지금 Spring 이전에 Appconfig는 계속해서 유저가 접근할 때마다 객체를 생성한다
2. 싱글톤 패턴
싱글톤 패턴이란
클래스의 인스턴스가 딱 하나만 생성되는 것을 보장하는 디자인 패턴이다. 그래서 객체 인스턴스를 2개 이상 생성하지 못하도록 막는다.
- private 생성자를 사용해서 외부에서 임의로 생성하지 못하게 한다.
public class SingletonService { //클래스 레벨에 올라가기 때문에 하나만 생성 private static final SingletonService instance = new SingletonService(); //JVM이 실행될 때 객체가 생성되서 인스턴스를 넣어둔다. public static SingletonService getInstance(){ return instance; } private SingletonService(){ //private 생성자를 통하여 새로운 생성을 제한한다. } }
- 해당 객체의 인스턴스가 필요하면 getInstance() 메서드를 통해서만 조회할 수 있다.
- 딱 한개의 개발 인스턴스만 존재함으로, private으로 생성자를 사용해서 new 키워드로 객체 인스턴스가 생성되는 것을 막는다.
@Test @DisplayName("싱글톤 패턴을 사용한 객체") void singletonServiceTest(){ SingletonService singletonService1 = SingletonService.getInstance(); SingletonService singletonService2 = SingletonService.getInstance(); Assertions.assertThat(singletonService1).isEqualTo(singletonService2); }
싱글톤 패턴에는 어떤 문제점이 있을까?
- 구현하는 코드가 많이 들어간다.
- 클라이언트가 구체 클래스에 의존한다(dip 위반)
- 테스트하기 어렵다
- 내부 속성에 대한 변경이 정말 어렵다.
3. 싱글톤 컨테이너
- 스프링 컨테이너느 싱글톤 패턴을 적용하지 않아도 객체 인스턴스를 싱글톤으로 관리한다.
- 스프링 컨테이너는 싱글톤 컨테이너 역할을 한다. 싱글톤 객체를 생성하고 관리하는 기능을 싱글톤 레지스트리라고 한다.
@Test @DisplayName("스프링 컨테이너에서 싱글톤 확인") void springContainer(){ //AppConfig appConfig = new AppConfig(); ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class); //case1 호출할 때마다 객체를 생성 MemberService memberService1 = ac.getBean("memberService", MemberService.class); //case2 호출할 때마다 객체를 생성 2 MemberService memberService2 = ac.getBean("memberService", MemberService.class); //memberService1 != memberService2 Assertions.assertThat(memberService1).isSameAs(memberService2); }
- 해당 테스트에서 getBean을 통해서 객체를 반환한다.
- 두개의 객체를 비교하였을 때 같은지에 대한 테스트를 진행한다.
- 스프링 컨테이너는 싱글톤이지만, 새로운 객체를 생성해서 반환하는 기능도 사용할 수는 있다.
4. 싱글톤 방식의 주의점
싱글톤 방식을 사용할 때 같은 인스턴스를 공유하기 때문에 값 접근에 있어 정말 조심해야 한다.
- 특정 클라이언트에 의존적인 필드가 있어서는 안된다.
- 클라이언트가 값을 변경할 수 있는 필드가 있으면 안된다.
- 필드 대신에 자바에서 공유되지 않는 지역변수, 파라미터 등을 사용해야 한다.
- 가급적 읽기만 가능하여야 한다.
예시
public class StatefulService { //상태 유지 필드 private int price; public void order(String name, int price){ System.out.println("name = " + name + ", price = " + price); this.price = price; // 해당 부분이 문제가 된다 } public int getPrice(){ return price; } }
- price라는 필드를 공유하고, 주문과 주문한 금액을 조회하는 로직이 있다.
class StatefulServiceTest { @Test void statefulServiceSingleton(){ ApplicationContext ac = new AnnotationConfigApplicationContext(TestConfig.class); StatefulService statefulService1 = ac.getBean(StatefulService.class); StatefulService statefulService2 = ac.getBean(StatefulService.class); //test1에서 만원짜리 물품을 주문 statefulService1.order("test1", 10000); //test2에서 2만원짜리 물품을 주문 statefulService2.order("test2", 20000); //test1에서 주문 금액을 조회 int price = statefulService1.getPrice(); System.out.println("price = " + price); //2만원이 아니니깐 틀린다. Assertions.assertThat(statefulService1.getPrice()).isNotEqualTo(20000); } static class TestConfig{ @Bean public StatefulService statefulService(){ return new StatefulService(); } } }
- 만약 주문 test1과 test2가 생성되었을 때
- test1의 주문 금액을 가져오면 어떻게될까?
같은 객체로 필드를 공유하였기 때문에 20000원이 된다.
해결 방법
public int order(String name, int price){ return price; }
- price 부분을 int로 넘겨주면 된다. (지역변수 사용)
5. 싱글톤 컨테이너 @Configuration
Appconfig를 의심해라
@Configuration public class AppConfig { //생성자를 통한 참조를 주입 @Bean public MemberService memberService(){ return new MemberServiceImpl(memberRepository()); } @Bean private static MemoryMemberRepository memberRepository() { return new MemoryMemberRepository(); } @Bean public OrderService orderService(){ return new OrderServiceImpl(memberRepository(), discountPolicy()); } @Bean private static DiscountPolicy discountPolicy() { return new RateDiscountPolicy(); } }
- 해당 코드는 다음과 같이 의존성 주입을 통해서 객체를 생성한다.
- memberService → new MemoryMemberRepository
- orderService → new MemoryMemberRepository
객체가 두번 생성되는데 싱글톤이 유지가 될 수 있을까?
확인해보자
- MemberServiceImpl.java 코드 변경
public class MemberServiceImpl implements MemberService{ private MemberRepository memberRepository; //생성자를 통한 의존성 주입. public MemberServiceImpl(MemberRepository memberRepository) { this.memberRepository = memberRepository; } @Override public void join(Member member) { memberRepository.save(member); } @Override public Member findMember(Long memberId) { return memberRepository.findById(memberId); } //추가 //for test public MemberRepository getMemberRepository(){ return memberRepository; } }
- OrderServiceImpl.java 코드 변경
package com.hello.core.order; import com.hello.core.discount.DiscountPolicy; import com.hello.core.member.Member; import com.hello.core.member.MemberRepository; public class OrderServiceImpl implements OrderService{ //private final MemberRepository memberRepository = new MemoryMemberRepository(); //private final DiscountPolicy discountPolicy = new FixDiscountPolicy(); //고정 금액 할인 //discountpolicy 변경 //private final DiscountPolicy discountPolicy = new RateDiscountPolicy(); // discountpolicy를 rate로 변경 /*after app config*/ private final MemberRepository memberRepository; private DiscountPolicy discountPolicy; // 인터페이스에만 의존하도록 설정 //nullpoint exception public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy discountPolicy) { this.memberRepository = memberRepository; this.discountPolicy = discountPolicy; } @Override public Order createOrder(Long memberId, String itemName, int itemPrice) { Member member = memberRepository.findById(memberId); int discountPrice = discountPolicy.discount(member, itemPrice); return new Order(memberId, itemName, itemPrice, discountPrice); } //test용 public MemberRepository getMemberRepository() { return memberRepository; } }
- 싱글톤 테스트
public class ConfigurationSingletonTest { @Test void configurationTest(){ AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class); MemberServiceImpl MemberServiceImpl = ac.getBean("memberService", MemberServiceImpl.class); OrderServiceImpl orderService = ac.getBean("orderService", OrderServiceImpl.class); MemberRepository memberRepository = ac.getBean("memberRepository", MemberRepository.class); MemberRepository memberRepository1 = MemberServiceImpl.getMemberRepository(); MemberRepository memberRepository2 = orderService.getMemberRepository(); System.out.println("memberService에서 호출한 memberRepository = " + memberRepository1); System.out.println("orderService에서 호출한 memberRepository" + memberRepository2); System.out.println("그냥 호출한 memberRepository" + memberRepository); Assertions.assertThat(memberRepository1).isSameAs(memberRepository2); } }
- 해당 테스트는 성공이라고 뜬다.
- 분명 객체는 3번 생성할텐데 왜 다 같은 값이 나오는걸까?
AppConfig도 결국 Bean이다
앞서 스프링 컨테이너는 싱글톤 레지스트리라고 말했다. 하지만 아까처럼 3번 호출 하는 경우 다른 객체가 생성이 된다. 그래서 스프링은 클래스의 바이트코드를 조작하는 라이브러리가 있다.
한번 확인해보자
@Test void configureationTest2(){ ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class); AppConfig bean = ac.getBean(AppConfig.class); System.out.println("bean = " + bean.getClass()); //EnhancerBySpringCGLIB$$ce961606 }
- 이상한 Enhance 어쩌구가 나온다.
- 스프링은 CGLIB라는 바이트코드 조작 라이브러릴 사용해서 AppConfig 클래스를 상속받은 다른 클래스를 만들고, 그 다른 클래스를 스프링 빈으로 등록한다.
- 해당 라이브러리는 스프링 Configureation에서 싱글톤을 보장해준다.
- 해당 라이브러리는 스프링 컨테이너를 조회해서 스프링 컨테이너에 등록된 Bean이면 반환하고, 없으면 새로 생성하고 스프링 컨테이너에 등록한다.
Spring에서 Bean을 Container에 등록할 때 @Configuration을 써야 하는 이유
- Configuration 어노테이션을 붙이지 않아도 Bean은 등록 된다.
- 하지만 CGLIB 기술이 적용되어 있지 않기 때문에 싱글톤 보장이 안된다.
- 따라서 DI 컨테이너를 작성할 때는 꼭 Configuration 어노테이션을 사용하길 바란다.